Skip to main content

Episode 3

Introduction

This tutorial continues on the second tutorial and adds another more complex block. In this tutorial we will learn the following things:

  • Block properties
  • User interface (GUI)
  • Integration with other mods (The One Probe)
  • Networking

Block properties

Let's start by adding a new block (ProcessorBlock). We will add to this file in steps. First let's define the block with associated block properties. We also need a block entity, so we'll add that too (newBlockEntity and getTicker are for that).

With createBlockStateDefinition we define the block properties. We will use four boolean properties to indicate whether a button is pressed or not. We will use these later to render the block. In addition, we also add a standard FACING property to indicate the direction the block is facing.

The addition of these properties will cause our block to have a different blockstate for each combination of these properties. That means that there will be a total of 16 times 6 = 96 different blockstates. This is a good amount but not dramatic. It's ok for a block to have hundreds of properties but don't overdo it. In the next tutorial we'll show a case where it is better to use a different system.

The getPlacementState method is used to set the initial state of the block when it is placed. In this case we set the FACING property to the direction the player is looking at and the button properties to false.

Because our block is not fully opaque we need to define a shape for it. We do that with the getShape method. We define a different shape for each direction. The shape is defined as a VoxelShape which is a collection of boxes. In this case we define a box that is fit exactly the shape we will define later in our model.

public class ProcessorBlock extends Block implements EntityBlock {

    public static final BooleanProperty BUTTON00 = BooleanProperty.create("button00");
    public static final BooleanProperty BUTTON01 = BooleanProperty.create("button01");
    public static final BooleanProperty BUTTON10 = BooleanProperty.create("button10");
    public static final BooleanProperty BUTTON11 = BooleanProperty.create("button11");

    private static final VoxelShape SHAPE_DOWN = Shapes.box(0, 2.0/16, 0, 1, 1, 1);
    private static final VoxelShape SHAPE_UP = Shapes.box(0, 0, 0, 1, 14.0/16, 1);
    private static final VoxelShape SHAPE_NORTH = Shapes.box(0, 0, 2.0/16, 1, 1, 1);
    private static final VoxelShape SHAPE_SOUTH = Shapes.box(0, 0, 0, 1, 1, 14.0/16);
    private static final VoxelShape SHAPE_WEST = Shapes.box(2.0/16, 0, 0, 1, 1, 1);
    private static final VoxelShape SHAPE_EAST = Shapes.box(0, 0, 0, 14.0/16, 1, 1);

    public ProcessorBlock() {
        // Let our block behave like a metal block
        super(Properties.of()
                .strength(3.5F)
                .noOcclusion()
                .requiresCorrectToolForDrops()
                .sound(SoundType.METAL));
    }

    @Override
    public VoxelShape getShape(BlockState state, BlockGetter getter, BlockPos pos, CollisionContext context) {
        return switch (state.getValue(BlockStateProperties.FACING)) {
            case DOWN -> SHAPE_DOWN;
            case UP -> SHAPE_UP;
            case NORTH -> SHAPE_NORTH;
            case SOUTH -> SHAPE_SOUTH;
            case WEST -> SHAPE_WEST;
            case EAST -> SHAPE_EAST;
        };
    }

    @Nullable
    @Override
    public BlockEntity newBlockEntity(BlockPos pos, BlockState state) {
        return new ProcessorBlockEntity(pos, state);
    }

    @Nullable
    @Override
    public <T extends BlockEntity> BlockEntityTicker<T> getTicker(Level level, BlockState state, BlockEntityType<T> type) {
        if (level.isClientSide) {
            // We don't have anything to do on the client side
            return null;
        } else {
            // Server side we delegate ticking to our block entity
            return (lvl, pos, st, blockEntity) -> {
                if (blockEntity instanceof ProcessorBlockEntity be) {
                    be.tickServer();
                }
            };
        }
    }

    @Nullable
    @Override
    public BlockState getStateForPlacement(BlockPlaceContext context) {
        return this.defaultBlockState()
                .setValue(BlockStateProperties.FACING, context.getNearestLookingDirection().getOpposite())
                .setValue(BUTTON00, false)
                .setValue(BUTTON01, false)
                .setValue(BUTTON10, false)
                .setValue(BUTTON11, false);
    }

    @Override
    protected void createBlockStateDefinition(StateDefinition.Builder<Block, BlockState> builder) {
        builder.add(BlockStateProperties.FACING, BUTTON00, BUTTON01, BUTTON10, BUTTON11);
    }
}

BlockState datagen

We want our block to look like this:

processor

So basically it's a block with four buttons. The buttons can be pressed or not.

Let's first concentrate on the rendering of this block. We will use a static json model for this and we want to use datagen. So let's look at TutBlockStates. Basically we add a new registerProcessor method. That method will first make the base model, and then it will make a multi-part model for all the buttons in all their possible states. The base model is a simple cube with a texture on all sides. Using the vanilla multipart system it is possible to make our model conditional. That means that all the button models are only added if the corresponding button is pressed. This is done by examining the blockstate properties.

public class TutBlockStates extends BlockStateProvider {

    @Override
    protected void registerStatesAndModels() {
        ...
        registerProcessor();
    }

    private void registerProcessor() {
        RegistryObject<ProcessorBlock> processor = Registration.PROCESSOR_BLOCK;
        String path = "processor";

        BlockModelBuilder base = models().getBuilder("block/" + path + "_main");
        base.parent(models().getExistingFile(mcLoc("cube")));

        base.element()
                .from(0f, 0f, 0f)
                .to(16f, 14f, 16f)
                .allFaces((direction, faceBuilder) -> faceBuilder.texture("#txt"))
                .end();

        base.texture("txt", modLoc("block/processor_main"));
        base.texture("particle", modLoc("block/processor_main"));

        base.renderType("solid");

        createProcessorModel(processor.get(), path, base);
    }

    private void createProcessorModel(Block block, String path, BlockModelBuilder frame) {
        BlockModelBuilder singleOff00 = buttonBuilderOff(path, "singleoff00", 2, 14, 2);
        BlockModelBuilder singleOn00 = buttonBuilderOn(path, "singleon00", 2, 14, 2);
        BlockModelBuilder singleOff10 = buttonBuilderOff(path, "singleoff10", 10, 14, 2);
        BlockModelBuilder singleOn10 = buttonBuilderOn(path, "singleon10", 10, 14, 2);
        BlockModelBuilder singleOff01 = buttonBuilderOff(path, "singleoff01", 2, 14, 10);
        BlockModelBuilder singleOn01 = buttonBuilderOn(path, "singleon01", 2, 14, 10);
        BlockModelBuilder singleOff11 = buttonBuilderOff(path, "singleoff11", 10, 14, 10);
        BlockModelBuilder singleOn11 = buttonBuilderOn(path, "singleon11", 10, 14, 10);

        MultiPartBlockStateBuilder bld = getMultipartBuilder(block);

        // For all six directions we add models that are rotated accordingly
        for (Direction dir : Direction.values()) {
            int angleOffset = dir.getAxis().isVertical() ? 0 : (((int) dir.toYRot()) + 180) % 360;
            int rotationX = dir == Direction.DOWN ? 180 : dir.getAxis().isHorizontal() ? 90 : 0;
            // Add the base model
            bld.part().modelFile(frame).rotationX(rotationX).rotationY(angleOffset).addModel().condition(BlockStateProperties.FACING, dir).end();
            // Add all the button models conditionally for this orientation. Both the 'off' and the 'on' state
            bld.part().modelFile(singleOff00).rotationX(rotationX).rotationY(angleOffset).addModel().condition(BlockStateProperties.FACING, dir).condition(ProcessorBlock.BUTTON00, false);
            bld.part().modelFile(singleOn00).rotationX(rotationX).rotationY(angleOffset).addModel().condition(BlockStateProperties.FACING, dir).condition(ProcessorBlock.BUTTON00, true);
            bld.part().modelFile(singleOff10).rotationX(rotationX).rotationY(angleOffset).addModel().condition(BlockStateProperties.FACING, dir).condition(ProcessorBlock.BUTTON10, false);
            bld.part().modelFile(singleOn10).rotationX(rotationX).rotationY(angleOffset).addModel().condition(BlockStateProperties.FACING, dir).condition(ProcessorBlock.BUTTON10, true);
            bld.part().modelFile(singleOff01).rotationX(rotationX).rotationY(angleOffset).addModel().condition(BlockStateProperties.FACING, dir).condition(ProcessorBlock.BUTTON01, false);
            bld.part().modelFile(singleOn01).rotationX(rotationX).rotationY(angleOffset).addModel().condition(BlockStateProperties.FACING, dir).condition(ProcessorBlock.BUTTON01, true);
            bld.part().modelFile(singleOff11).rotationX(rotationX).rotationY(angleOffset).addModel().condition(BlockStateProperties.FACING, dir).condition(ProcessorBlock.BUTTON11, false);
            bld.part().modelFile(singleOn11).rotationX(rotationX).rotationY(angleOffset).addModel().condition(BlockStateProperties.FACING, dir).condition(ProcessorBlock.BUTTON11, true);
        }
    }

    private BlockModelBuilder buttonBuilderOn(String path, String name, int x, int y, int z) {
        return models().getBuilder("block/" + path + "/" + name)
                .element()
                .from(x, y, z)
                .to(x+4, y+2, z+4)
                .allFaces((direction, faceBuilder) -> faceBuilder.texture("#button"))
                .end()
                .texture("button", modLoc("block/processor_on"));
    }

    private BlockModelBuilder buttonBuilderOff(String path, String name, int x, int y, int z) {
        return models().getBuilder("block/" + path + "/" + name)
                .element()
                .from(x, y, z)
                .to(x+4, y+.5f, z+4)
                .allFaces((direction, faceBuilder) -> faceBuilder.texture("#button"))
                .end()
                .texture("button", modLoc("block/processor_off"));
    }
}

We also need to add an entry for the item:

public class TutItemModels extends ItemModelProvider {

    ...

    @Override
    protected void registerModels() {
        ...
        withExistingParent(Registration.PROCESSOR_BLOCK.getId().getPath(), modLoc("block/processor_main"));
    }
}

When you generate this you'll get a lot of models. You can see them in the generated folder.

The Block Entity

Let's start with the basics for our block entity. This is similar to the complex block entity from the previous tutorial. In this example we have 1 input slot and 6 output slots. We also have sided access. The output slots are only accessible from the bottom. The input slot is only accessible from the other sides. When no side is specified we return the base item handler.

The input and output handlers are separate fields. However, we have three lazy optional item handlers because we want to return a combined item handler in case the capability is requested with null side. The NeoForge CombinedInvWrapper is a wrapper around multiple item handlers that makes them appear as a single item handler.

We define our own AdaptedItemHandler that allows us to adapt an existing item handler and restrict the operations that are allowed. For example the input item handler only allows insertion and the output item handler only allows extraction.

The reason we don't restrict these operations in the item handler itself is that we want to be able to use inputItems and outputItems unrestricted in our block entity. The restriction is only for automation (like hoppers).

public class ProcessorBlockEntity extends BlockEntity {

    public static final int SLOT_INPUT = 0;
    public static final int SLOT_INPUT_COUNT = 1;

    public static final int SLOT_OUTPUT = 0;
    public static final int SLOT_OUTPUT_COUNT = 6;

    public static final int SLOT_COUNT = SLOT_INPUT_COUNT + SLOT_OUTPUT_COUNT;

    private final ItemStackHandler inputItems = createItemHandler(SLOT_INPUT_COUNT);
    private final ItemStackHandler outputItems = createItemHandler(SLOT_OUTPUT_COUNT);
    private final Lazy<IItemHandler> itemHandler = Lazy.of(() -> new CombinedInvWrapper(inputItems, outputItems));
    private final Lazy<IItemHandler> inputItemHandler = Lazy.of(() -> new AdaptedItemHandler(inputItems) {
        @Override
        public @NotNull ItemStack extractItem(int slot, int amount, boolean simulate) {
            return ItemStack.EMPTY;
        }
    });
    private final Lazy<IItemHandler> outputItemHandler = Lazy.of(() -> new AdaptedItemHandler(outputItems) {
        @Override
        public @NotNull ItemStack insertItem(int slot, @NotNull ItemStack stack, boolean simulate) {
            return stack;
        }
    });

    public ProcessorBlockEntity(BlockPos pos, BlockState state) {
        super(Registration.PROCESSOR_BLOCK_ENTITY.get(), pos, state);
    }

    public ItemStackHandler getInputItems() {
        return inputItems;
    }

    public ItemStackHandler getOutputItems() {
        return outputItems;
    }

    public Lazy<IItemHandler> getItemHandler() {
        return itemHandler;
    }

    public Lazy<IItemHandler> getInputItemHandler() {
        return inputItemHandler;
    }

    public Lazy<IItemHandler> getOutputItemHandler() {
        return outputItemHandler;
    }

    @Nonnull
    private ItemStackHandler createItemHandler(int slots) {
        return new ItemStackHandler(slots) {
            @Override
            protected void onContentsChanged(int slot) {
                setChanged();
            }
        };
    }

    @Override
    protected void saveAdditional(CompoundTag tag) {
        super.saveAdditional(tag);
        tag.put(ITEMS_INPUT_TAG, inputItems.serializeNBT());
        tag.put(ITEMS_OUTPUT_TAG, outputItems.serializeNBT());
    }

    @Override
    public void load(CompoundTag tag) {
        super.load(tag);
        if (tag.contains(ITEMS_INPUT_TAG)) {
            inputItems.deserializeNBT(tag.getCompound(ITEMS_INPUT_TAG));
        }
        if (tag.contains(ITEMS_OUTPUT_TAG)) {
            outputItems.deserializeNBT(tag.getCompound(ITEMS_OUTPUT_TAG));
        }
    }
}

We also have to register this capability in the main class. Note how we return another capability depending on the direction:

    private void registerCapabilities(RegisterCapabilitiesEvent event) {
        ...
        event.registerBlockEntity(Capabilities.ItemHandler.BLOCK, Registration.PROCESSOR_BLOCK_ENTITY.get(), (o, direction) -> {
            if (direction == null) {
                return o.getItemHandler().get();
            }
            if (direction == Direction.DOWN) {
                return o.getOutputItemHandler().get();
            }
            return o.getInputItemHandler().get();
        });
    }

We also need a new tool class (AdaptedItemHandler) to help us with the item handler. This is basically a wrapper around the ItemStackHandler that allows us to restrict the operations that are allowed on another item handler. The only thing this class does is delegate all operations to the wrapped item handler.

public class AdaptedItemHandler implements IItemHandlerModifiable {

    private final IItemHandlerModifiable handler;

    public AdaptedItemHandler(IItemHandlerModifiable handler) {
        this.handler = handler;
    }

    @Override
    public void setStackInSlot(int slot, @NotNull ItemStack stack) {
        handler.setStackInSlot(slot, stack);
    }

    @Override
    public int getSlots() {
        return handler.getSlots();
    }

    @Override
    public @NotNull ItemStack getStackInSlot(int slot) {
        return handler.getStackInSlot(slot);
    }

    @Override
    public @NotNull ItemStack insertItem(int slot, @NotNull ItemStack stack, boolean simulate) {
        return handler.insertItem(slot, stack, simulate);
    }

    @Override
    public @NotNull ItemStack extractItem(int slot, int amount, boolean simulate) {
        return handler.extractItem(slot, amount, simulate);
    }

    @Override
    public int getSlotLimit(int slot) {
        return handler.getSlotLimit(slot);
    }

    @Override
    public boolean isItemValid(int slot, @NotNull ItemStack stack) {
        return handler.isItemValid(slot, stack);
    }
}

Registration

We also need to add code to register our block and block entity:

public class Registration {

    ...
    public static final DeferredBlock<ProcessorBlock> PROCESSOR_BLOCK = BLOCKS.register("processor_block", ProcessorBlock::new);
    public static final DeferredItem<Item> PROCESSOR_BLOCK_ITEM = ITEMS.register("processor_block", () -> new BlockItem(PROCESSOR_BLOCK.get(), new Item.Properties()));
    public static final Supplier<BlockEntityType<ProcessorBlockEntity>> PROCESSOR_BLOCK_ENTITY = BLOCK_ENTITIES.register("processor_block",
            () -> BlockEntityType.Builder.of(ProcessorBlockEntity::new, PROCESSOR_BLOCK.get()).build(null));

    ...
    static void addCreative(BuildCreativeModeTabContentsEvent event) {
        if (event.getTabKey() == CreativeModeTabs.BUILDING_BLOCKS) {
            ...
            event.accept(PROCESSOR_BLOCK_ITEM);
        }
    }

User interface

Before we continue with the rest of the block entity functionality, let's first talk about the user interface for this block. We want a user interface that looks like this:

Processor block user interface

User interfaces in Minecraft are a bit more complicated since they have both a client side and a server side component. The class that ties both sides together is a container represented by AbstractContainerMenu. This is the class that is responsible for sending the data from the server to the client and back. The client side is represented by a Screen class. This is the class that is responsible for rendering the user interface and sending the user input back to the server.

Container

Let's start with the container. We need to create a new class that extends AbstractContainerMenu.

In the container we need to add the slots that we want to show in the user interface. In our case we want to show slots from three inventories: the input inventory, the output inventory and the player. Every slot has a source inventory and a slot index. It also has an actual location on the screen.

A very important function that you have to override in a container is quickMoveStack. This function is called when the player shift-clicks on a slot. It is responsible for moving the item to a sensible place depending on the context. For example, in our case we want to move items from the player inventory to the input slot and from the output slot to the player inventory.

The stillValid function checks if the player is still close enough to the block to be able to interact with it.

Containers can also be used to synchronize 16-bit integers from server to client. This is done by calling addDataSlot. We don't use that in this tutorial.

public class ProcessorContainer extends AbstractContainerMenu {

    private final BlockPos pos;

    public ProcessorContainer(int windowId, Player player, BlockPos pos) {
        super(Registration.PROCESSOR_CONTAINER.get(), windowId);
        this.pos = pos;
        if (player.level().getBlockEntity(pos) instanceof ProcessorBlockEntity processor) {
            addSlot(new SlotItemHandler(processor.getInputItems(), SLOT_INPUT, 64, 24));
            addSlot(new SlotItemHandler(processor.getOutputItems(), ProcessorBlockEntity.SLOT_OUTPUT+0, 108, 24));
            addSlot(new SlotItemHandler(processor.getOutputItems(), ProcessorBlockEntity.SLOT_OUTPUT+1, 126, 24));
            addSlot(new SlotItemHandler(processor.getOutputItems(), ProcessorBlockEntity.SLOT_OUTPUT+2, 144, 24));
            addSlot(new SlotItemHandler(processor.getOutputItems(), ProcessorBlockEntity.SLOT_OUTPUT+3, 108, 42));
            addSlot(new SlotItemHandler(processor.getOutputItems(), ProcessorBlockEntity.SLOT_OUTPUT+4, 126, 42));
            addSlot(new SlotItemHandler(processor.getOutputItems(), ProcessorBlockEntity.SLOT_OUTPUT+5, 144, 42));
        }
        layoutPlayerInventorySlots(player.getInventory(), 10, 70);
    }

    private int addSlotRange(Container playerInventory, int index, int x, int y, int amount, int dx) {
        for (int i = 0 ; i < amount ; i++) {
            addSlot(new Slot(playerInventory, index, x, y));
            x += dx;
            index++;
        }
        return index;
    }

    private int addSlotBox(Container playerInventory, int index, int x, int y, int horAmount, int dx, int verAmount, int dy) {
        for (int j = 0 ; j < verAmount ; j++) {
            index = addSlotRange(playerInventory, index, x, y, horAmount, dx);
            y += dy;
        }
        return index;
    }

    private void layoutPlayerInventorySlots(Container playerInventory, int leftCol, int topRow) {
        // Player inventory
        addSlotBox(playerInventory, 9, leftCol, topRow, 9, 18, 3, 18);

        // Hotbar
        topRow += 58;
        addSlotRange(playerInventory, 0, leftCol, topRow, 9, 18);
    }

    @Override
    public ItemStack quickMoveStack(Player player, int index) {
        ItemStack itemstack = ItemStack.EMPTY;
        Slot slot = this.slots.get(index);
        if (slot.hasItem()) {
            ItemStack stack = slot.getItem();
            itemstack = stack.copy();
            if (index < SLOT_COUNT) {
                if (!this.moveItemStackTo(stack, SLOT_COUNT, Inventory.INVENTORY_SIZE + SLOT_COUNT, true)) {
                    return ItemStack.EMPTY;
                }
            }
            if (!this.moveItemStackTo(stack, SLOT_INPUT, SLOT_INPUT+1, false)) {
                if (index < 27 + SLOT_COUNT) {
                    if (!this.moveItemStackTo(stack, 27 + SLOT_COUNT, 36 + SLOT_COUNT, false)) {
                        return ItemStack.EMPTY;
                    }
                } else if (index < Inventory.INVENTORY_SIZE + SLOT_COUNT && !this.moveItemStackTo(stack, SLOT_COUNT, 27 + SLOT_COUNT, false)) {
                    return ItemStack.EMPTY;
                }
            }

            if (stack.isEmpty()) {
                slot.set(ItemStack.EMPTY);
            } else {
                slot.setChanged();
            }

            if (stack.getCount() == itemstack.getCount()) {
                return ItemStack.EMPTY;
            }

            slot.onTake(player, stack);
        }

        return itemstack;
    }

    @Override
    public boolean stillValid(Player player) {
        return stillValid(ContainerLevelAccess.create(player.level(), pos), player, Registration.PROCESSOR_BLOCK.get());
    }
}

Screen

Now that we have the container, we can create the screen. The screen is responsible for rendering the user interface and sending the user input back to the server. The screen is created on the client side only, so we don't need to worry about the server side.

Like we did in the past we use a ResourceLocation to point to the texture that we want to use for the background. We also need to override the renderBg function to actually render it.

That's all you need to do. The container takes care of the rest.

public class ProcessorScreen extends AbstractContainerScreen<ProcessorContainer> {

    private final ResourceLocation GUI = new ResourceLocation(Tutorial2Block.MODID, "textures/gui/processor.png");

    public ProcessorScreen(ProcessorContainer container, Inventory inventory, Component title) {
        super(container, inventory, title);
        this.inventoryLabelY = this.imageHeight - 110;
    }

    @Override
    protected void renderBg(GuiGraphics graphics, float partialTicks, int mouseX, int mouseY) {
        int relX = (this.width - this.imageWidth) / 2;
        int relY = (this.height - this.imageHeight) / 2;
        graphics.blit(GUI, relX, relY, 0, 0, this.imageWidth, this.imageHeight);
    }
}

Registration

The last step is to register the container and the screen. The container is registered in the Registration class. Note that we use IForgeMenuType.create to create the menu type. Because we need to know the position of our processor in the container we use data (which is a PacketBuffer instance) to read the position from the packet. In the block we will pass this position to this packet:

public class Registration {

    ...
    public static final DeferredRegister<MenuType<?>> MENU_TYPES = DeferredRegister.create(BuiltInRegistries.MENU, Tutorial2Block.MODID);

    ...
    public static final Supplier<MenuType<ProcessorContainer>> PROCESSOR_CONTAINER = MENU_TYPES.register("processor_block",
            () -> IMenuTypeExtension.create((windowId, inv, data) -> new ProcessorContainer(windowId, inv.player, data.readBlockPos())));

    public static void init(IEventBus modEventBus) {
        ...
        MENU_TYPES.register(modEventBus);
    }
}

The screen needs to be registered in ClientSetup. Here we link the container with the screen so that when the client gets notified that a certain container is opened, it will open the correct screen.

Be aware of the enqueueWork function. This function is used to run code on the main thread. This is required because the MenuScreens.register function needs to be run on the main thread mod initialization can in principle happen on another thread.

Always use enqueueWork in a packet handler when you need to run code on the main thread!

@Mod.EventBusSubscriber(modid = MODID, bus = Mod.EventBusSubscriber.Bus.MOD, value = Dist.CLIENT)
public class ClientSetup {

    @SubscribeEvent
    public static void init(FMLClientSetupEvent event) {
        event.enqueueWork(() -> {
            MenuScreens.register(Registration.PROCESSOR_CONTAINER.get(), ProcessorScreen::new);
        });
    }

    ...
}

Opening the screen

Now we need to add code to the processor block to actually open the screen. Add this code to the block class. Note that we actually open the user interface on the server! That's important because it is the server side that initiates the container and the client side that renders the screen. By opening the container on the server the client will get notified and will open the corresponding screen.

The block position that is given to player.openMenu is used to send the position to the container. You need to give this position through a PacketBuffer because the container is created on the client side and the server side needs to send the position to the client.

public class ProcessorBlock extends Block implements EntityBlock {

    public static final String SCREEN_TUTORIAL_PROCESSOR = "tutorial.screen.processor";

    ...
    
    @Override
    public InteractionResult use(BlockState state, Level level, BlockPos pos, Player player, InteractionHand hand, BlockHitResult trace) {
        if (!level.isClientSide) {
            BlockEntity be = level.getBlockEntity(pos);
            if (be instanceof ProcessorBlockEntity) {
                MenuProvider containerProvider = new MenuProvider() {
                    @Override
                    public Component getDisplayName() {
                        return Component.translatable(SCREEN_TUTORIAL_PROCESSOR);
                    }

                    @Override
                    public AbstractContainerMenu createMenu(int windowId, Inventory playerInventory, Player playerEntity) {
                        return new ProcessorContainer(windowId, playerEntity, pos);
                    }
                };
                player.openMenu(containerProvider, buf -> buf.writeBlockPos(pos));
            } else {
                throw new IllegalStateException("Our named container provider is missing!");
            }
        }
        return InteractionResult.SUCCESS;
    }
}

Datagen

We already covered the blockstate, block model and item model. In this section we will quickly go over the rest of the datagen. First you need to add this block to TutBlockTags.

For the language provider add the following lines:

        add(Registration.PROCESSOR_BLOCK.get(), "Processor");
        add(ProcessorBlock.SCREEN_TUTORIAL_PROCESSOR, "Processor");

Add the following recipe to TutRecipes:

        ShapedRecipeBuilder.shaped(RecipeCategory.MISC, Registration.PROCESSOR_BLOCK.get())
                .pattern("iii")
                .pattern("rgr")
                .pattern("iii")
                .define('i', Tags.Items.INGOTS_IRON)
                .define('r', Tags.Items.DUSTS_REDSTONE)
                .define('g', Tags.Items.GLASS)
                .group("tutorial")
                .unlockedBy("has_redstone", InventoryChangeTrigger.TriggerInstance.hasItems(
                        ItemPredicate.Builder.item().of(Tags.Items.DUSTS_REDSTONE).build()))
                .save(consumer);

Finally, we need to generate the loot tables. Because our processor has two inventories (one for input and one for output) we need to generalize our function a bit to allow for this.

Basically in createStandardTable we add an extra parameter tags which is a list of things that we want to copy from the block entity to the loot table. We then loop over this list and add a CopyNbtFunction for each tag.

public class TutLootTables extends VanillaBlockLoot {

    @Override
    protected void generate() {
        dropSelf(Registration.SIMPLE_BLOCK.get());
        createStandardTable(Registration.COMPLEX_BLOCK.get(), Registration.COMPLEX_BLOCK_ENTITY.get(), ComplexBlockEntity.ITEMS_TAG);
        createStandardTable(Registration.PROCESSOR_BLOCK.get(), Registration.PROCESSOR_BLOCK_ENTITY.get(), ProcessorBlockEntity.ITEMS_INPUT_TAG, ProcessorBlockEntity.ITEMS_OUTPUT_TAG);
    }

    ...

    private void createStandardTable(Block block, BlockEntityType<?> type, String... tags) {
        LootPoolSingletonContainer.Builder<?> lti = LootItem.lootTableItem(block);
        lti.apply(CopyNameFunction.copyName(CopyNameFunction.NameSource.BLOCK_ENTITY));
        for (String tag : tags) {
            lti.apply(CopyNbtFunction.copyData(ContextNbtProvider.BLOCK_ENTITY).copy(tag, "BlockEntityTag." + tag, CopyNbtFunction.MergeStrategy.REPLACE));
        }
        lti.apply(SetContainerContents.setContents(type).withEntry(DynamicLoot.dynamicEntry(new ResourceLocation("minecraft", "contents"))));

        LootPool.Builder builder = LootPool.lootPool()
                .setRolls(ConstantValue.exactly(1))
                .add(lti);
        add(block, LootTable.lootTable().withPool(builder));
    }
}

In World Interaction

We want to be able to interact with our block in the world. For this we need to add a few things.

Quadrant Detection

First we add a helper function to our block. This will be used to figure out on which part of the block the player clicked. This routine is a bit tricky because we need to take into account the direction the block is facing. We use the hit parameter to figure out where the player clicked on the block. This is a 3D coordinate. We then transform this coordinate to a 2D coordinate, and then we can easily figure out which quadrant is hit.

public class ProcessorBlock extends Block implements EntityBlock {

    ...

    public static int getQuadrant(Direction facing, Vec3 hit) {
        // We want to transform this 3D location to 2D so that we can more easily check which quadrant is hit
        double x = getXFromHit(facing, hit);
        double y = getYFromHit(facing, hit);
        // Calculate the correct quadrant
        int quadrant = 0;
        if (x < .5 && y > .5) {
            quadrant = 1;
        } else if (x > .5 && y < .5) {
            quadrant = 2;
        } else if (x > .5 && y > .5) {
            quadrant = 3;
        }
        return quadrant;
    }

    private static double getYFromHit(Direction facing, Vec3 hit) {
        return switch (facing) {
            case UP -> 1 - hit.x;
            case DOWN -> 1 - hit.x;
            case NORTH -> 1 - hit.x;
            case SOUTH -> hit.x;
            case WEST -> hit.z;
            case EAST -> 1 - hit.z;
        };
    }

    private static double getXFromHit(Direction facing, Vec3 hit) {
        return switch (facing) {
            case UP -> hit.z;
            case DOWN -> 1 - hit.z;
            case NORTH -> hit.y;
            case SOUTH -> hit.y;
            case WEST -> hit.y;
            case EAST -> hit.y;
        };
    }
}

Hitting the Block

Now we need to add the code that handles the interaction. Because right click is already used for opening the GUI, we will use left click. However, there are a few problems with left click:

  1. Left click is used for breaking blocks. We don't want to break our block when left-clicking it. Especially in Creative this can be a problem.
  2. The attack method in Block that would be ideal for us to use doesn't give us the location where the player hit the block so we can't use this for finding out which quadrant is hit.

Fortunately NeoForge provides us with an event that can solve both problems for us. This event is called PlayerInteractEvent.LeftClickBlock. We can use this event to cancel the block breaking and instead handle the interaction ourselves. We can also use the hitVec parameter to figure out which quadrant is hit.

To do this we add a new class called EventHandlers. Be aware that this event will be fired for every left click that the player does, so we need to make sure that we only handle the event for our own block. If we hit the face that has the buttons then we cancel the event so that the break doesn't happen. This also works in creative so that a creative player can still use the buttons. Hitting any other side will still break the block as usual.

Note out usage of SafeClientTools.getClientMouseOver(). This is a helper function that we added to get where the user hit the block. The reason that this is in a separate class is that this function only works client side and the LeftClickBlock event is fired on both client and server. We know we are on the client because we tested for this in the line above but this code still has to work server side too. That's why we can't directly use Minecraft.getInstance().hitResult.

This does mean that we only can detect this on the client, but it is the server that needs to know what quadrant is hit. For that reason we need to send a network packet to the server. In the next section we will explain how this works. For now, assume that PacketHitToServer will cause the quadrant to be sent to the server.

public class EventHandlers {

    @SubscribeEvent
    public void onPlayerInteract(PlayerInteractEvent.LeftClickBlock event) {
        BlockPos pos = event.getPos();
        Level level = event.getLevel();
        BlockState state = level.getBlockState(pos);
        if (state.is(Registration.PROCESSOR_BLOCK.get())) {
            Direction facing = state.getValue(BlockStateProperties.FACING);
            if (facing == event.getFace()) {
                event.setCanceled(true);
                if (level.isClientSide) {
                    HitResult hit = SafeClientTools.getClientMouseOver();
                    if (hit.getType() == HitResult.Type.BLOCK) {
                        // Subtract the position of our block from the location that we hit to get the relative location in 3D
                        Vec3 relative = hit.getLocation().subtract(pos.getX(), pos.getY(), pos.getZ());
                        int quadrant = ProcessorBlock.getQuadrant(facing, relative);
                        Channel.sendToServer(new PacketHitToServer(pos, quadrant));
                    }
                }
            }
        }
    }
}

We need to register this event handler on the NeoForge bus. Add this line to the constructor of our mod:

        NeoForge.EVENT_BUS.register(new EventHandlers());

Here is SafeClientTools:

public class SafeClientTools {
    public static HitResult getClientMouseOver() {
        return Minecraft.getInstance().hitResult;
    }
}

Networking

Check out the NeoForge documentation for more information on networking: https://docs.neoforged.net/docs/networking/

To set up networking we make a Channel class that takes care of a few things. First we need to listen to a new event that will be responsible for registering our packets. This event is fired on the mod bus. Secondly we add a few convenience methods to send packets to the server or to a specific player.

Note that in the onRegisterPayloadHandler method the registrat.play method is used to register a packet. The first parameter is the packet id. The second parameter is a function that creates a new instance of the packet. The third parameter is a consumer that takes the packet and handles it. In this case we only handle the packet on the server side:

public class Channel {

    public static void onRegisterPayloadHandler(RegisterPayloadHandlerEvent event) {
        final IPayloadRegistrar registrar = event.registrar(Tutorial2Block.MODID)
                .versioned("1.0")
                .optional();
        registrar.play(PacketHitToServer.ID, PacketHitToServer::create, handler -> handler
                .server(PacketHitToServer::handle));
    }

    public static <MSG extends CustomPacketPayload> void sendToServer(MSG message) {
        PacketDistributor.SERVER.noArg().send(message);
    }

    public static <MSG extends CustomPacketPayload> void sendToPlayer(MSG message, ServerPlayer player) {
        PacketDistributor.PLAYER.with(player).send(message);
    }
}

To register the event we need to add this line to the constructor of our mod:

        modEventBus.addListener(Channel::onRegisterPayloadHandler);

Now we need to create the packet itself. The data that we send to the server is the position of our processor block and the quadrant that was hit. We can use the FriendlyByteBuf class to write this data to a byte buffer. We also need to create a constructor that can read this data from the buffer. Normally network packets are handled on the networking thread so we have to take special care to make sure that our code gets handled on the main thread.

By convention packets are created as records and they have to implement CustomPacketPayload. We also make two create methods. One takes a FriendlyByteBuf and is used to read the data from the buffer. The other takes the data as parameters and is used to create a new packet.

The submitAsync method is used to make sure that our code gets handled on the main thread.

public record PacketHitToServer(BlockPos pos, int button) implements CustomPacketPayload {

    public static final ResourceLocation ID = new ResourceLocation(Tutorial2Block.MODID, "hit");

    public static PacketHitToServer create(FriendlyByteBuf buf) {
        return new PacketHitToServer(buf.readBlockPos(), buf.readByte());
    }

    public static PacketHitToServer create(BlockPos pos, int button) {
        return new PacketHitToServer(pos, button);
    }

    @Override
    public void write(FriendlyByteBuf buf) {
        buf.writeBlockPos(pos);
        buf.writeByte(button);
    }

    @Override
    public ResourceLocation id() {
        return ID;
    }

    public void handle(PlayPayloadContext ctx) {
        ctx.workHandler().submitAsync(() -> {
            ctx.player().ifPresent(player -> {
                if (player.level().getBlockEntity(pos) instanceof ProcessorBlockEntity processor) {
                    processor.hit(player, button);
                }
            });
        });
    }
}

Performing the action

Now we have to extend our block entity and implement the hit method there. There are a few important things happening here. In the hit method itself we check which quadrant was hit and set the corresponding button property to true. We also send a message to the player which action will be toggled. Each button is a toggle to let the processor do some action on the input item.

Note that block states can't be modified (they are immutable). So when we know which button is pressed and which property we need to change we need to create a new block state. We can do this by calling setValue on the block state. But in this case we need a toggle so we can use cycle instead. This will cycle through the possible values of the property (true or false in case of a boolean). Then we update the new blockstate in the world.

In the tickServer method we check if any of the buttons are pressed. If so we will do some processing on the current item. We will melt the item, break it, play a sound or spawn a new mob. Depending on this action we will either consume the item (in case it is a spawn egg for which we spawned a mob), just return the item unchanged (in case of the sound action), add one item from the loot table to the output or else place the result of melting the item in the output.

The insertOrInject method is used to insert the result of the operation in one of the output slots. If there is no room the item is ejected in the world. See here how we use ItemEntity to create an entity for the item which is then spawned into the world.

meltItem will use the recipe manager to find a melting recipe for the item. If there is one we will use assemble to perform the operation.

breakAsBlock will attempt to break the block as if it was placed in the world and then it will pick a random item from the resulting loot.

playSound will play the breaking sound that the input block would make if it was broken.

spawnMob only works if the item is a spawn egg. In that case we will spawn the mob in the world and consume the egg.

public class ProcessorBlockEntity extends BlockEntity {

    ...
    
    public static final String ACTION_MELT = "tutorial.message.melt";
    public static final String ACTION_BREAK = "tutorial.message.break";
    public static final String ACTION_SOUND = "tutorial.message.sound";
    public static final String ACTION_SPAWN = "tutorial.message.spawn";

    ...

    public void tickServer() {
        if (level.getGameTime() % 10 != 0) {
            return;
        }

        // Depending on the pressed buttons we will do some processing for the current item
        boolean button0 = getBlockState().getValue(ProcessorBlock.BUTTON10);
        boolean button1 = getBlockState().getValue(ProcessorBlock.BUTTON00);
        boolean button2 = getBlockState().getValue(ProcessorBlock.BUTTON11);
        boolean button3 = getBlockState().getValue(ProcessorBlock.BUTTON01);

        if (button0 || button1 || button2  || button3) {
            ItemStack stack = inputItems.extractItem(SLOT_INPUT, 1, false);
            if (!stack.isEmpty()) {
                // We have an item in the input slot. We will do some processing depending on
                // the pressed buttons and put the result in one of the output slots
                if (button0) {
                    insertOrEject(meltItem(stack));
                }
                if (button1) {
                    insertOrEject(breakAsBlock(stack));
                }
                if (button2) {
                    insertOrEject(playSound(stack));
                }
                if (button3) {
                    insertOrEject(spawnMob(stack));
                }
            }
        }
    }

    // Try to insert the item in the output. Eject if no room
    private void insertOrEject(ItemStack stack) {
        ItemStack itemStack = ItemHandlerHelper.insertItem(outputItems, stack, false);
        if (!itemStack.isEmpty()) {
            ItemEntity entityitem = new ItemEntity(level, worldPosition.getX()+.5, worldPosition.getY() + 1, worldPosition.getZ()+.5, itemStack);
            entityitem.setPickUpDelay(40);
            entityitem.setDeltaMovement(entityitem.getDeltaMovement().multiply(0, 1, 0));
            level.addFreshEntity(entityitem);
        }
    }

    private ItemStack meltItem(ItemStack stack) {
        SimpleContainer container = new SimpleContainer(stack);
        return level.getRecipeManager().getRecipeFor(RecipeType.SMELTING, container, this.level)
                .map(recipe -> recipe.assemble(container, level.registryAccess())).orElse(ItemStack.EMPTY);
    }

    private ItemStack breakAsBlock(ItemStack stack) {
        // Check if the item is a block item then get the loot that we would get when it is broken
        if (stack.getItem() instanceof BlockItem) {
            BlockItem blockItem = (BlockItem) stack.getItem();
            LootParams.Builder paramsBuilder = new LootParams.Builder((ServerLevel) level)
                    .withParameter(LootContextParams.ORIGIN, new Vec3(worldPosition.getX(), worldPosition.getY(), worldPosition.getZ()))
                    .withParameter(LootContextParams.TOOL, new ItemStack(Items.DIAMOND_PICKAXE));
            List<ItemStack> drops = blockItem.getBlock().getDrops(blockItem.getBlock().defaultBlockState(), paramsBuilder);
            // Return a random item from the drops
            if (drops.isEmpty()) {
                return ItemStack.EMPTY;
            }
            return drops.get(level.random.nextInt(drops.size()));
        } else {
            return stack;
        }
    }

    private ItemStack playSound(ItemStack stack) {
        if (stack.getItem() instanceof BlockItem blockItem) {
            Block block = blockItem.getBlock();
            SoundEvent sound = block.defaultBlockState().getSoundType().getBreakSound();
            level.playSound(null, worldPosition, sound, SoundSource.BLOCKS, 1, 1);
        }
        return stack;
    }

    private ItemStack spawnMob(ItemStack stack) {
        if (stack.getItem() instanceof SpawnEggItem spawnEggItem) {
            EntityType<?> type = spawnEggItem.getType(stack.getTag());
            type.spawn((ServerLevel)level, stack, null, worldPosition.above(), MobSpawnType.SPAWN_EGG, false, false);
        }
        return ItemStack.EMPTY;
    }

    public void hit(Player player, int button) {
        // Get the button property from the block state based on button parameter
        BooleanProperty property = switch (button) {
            case 0 -> ProcessorBlock.BUTTON10;
            case 1 -> ProcessorBlock.BUTTON00;
            case 2 -> ProcessorBlock.BUTTON11;
            case 3 -> ProcessorBlock.BUTTON01;
            default -> throw new IllegalStateException("Unexpected value: " + button);
        };
        // Get the right button message
        String message = switch (button) {
            case 0 -> ACTION_MELT;
            case 1 -> ACTION_BREAK;
            case 2 -> ACTION_SOUND;
            case 3 -> ACTION_SPAWN;
            default -> throw new IllegalStateException("Unexpected value: " + button);
        };
        // Toggle the value of this property in the blockstate
        BlockState newState = getBlockState().cycle(property);
        level.setBlockAndUpdate(worldPosition, newState);
        Boolean value = newState.getValue(property);

        // Send a message to the client with the new button state
        player.displayClientMessage(Component.translatable(message, value ? "On" : "Off"), true);

        // Play a button sound
        level.playSound(null, worldPosition, value ? SoundEvents.STONE_BUTTON_CLICK_ON : SoundEvents.STONE_BUTTON_CLICK_OFF, SoundSource.BLOCKS, 1, 1);
    }

    ...
}

The One Probe Integration

The One Probe is a mod that shows information about blocks when you look at them. By default it will show standard information (like the tool needed to break the block) but TOP has an API that you can use to add your own information. In this tutorial we will add information about the processing buttons to the TOP display.

For this to work we need to add TOP as a maven dependency in our build.gradle file. Luckily we have already done that in the previous tutorials.

Adding integration for The One Probe is very easy. Just add the TopCompatibility class. To get the API of The One Probe we use InterModComms and call the getTheOneProbe function. The register method first checks if TOP is loaded so it is safe to call this method even if TOP is not installed.

Note that you usually want your mod to work without TOP installed. So you should make sure to never use any TOP interfaces directly in your code. Always go through a compatibility layer. In this case it's the TopCompatibility class.

The registration function that TOP will call if it is present will allow us to register a provider for the probe information. We do that in the apply method. In addProbeInfo we first check if the block is a processor block. If it is we get the block state and then get the button properties from the block state. Using the API we then show the correct information.

Note that we also use ProcessorBlock.getQuadrant() to find out what button we are currently focusing.

public class TopCompatibility {

    public static void register() {
        if (!ModList.get().isLoaded("theoneprobe")) {
            return;
        }
        InterModComms.sendTo("theoneprobe", "getTheOneProbe", GetTheOneProbe::new);
    }


    public static class GetTheOneProbe implements Function<ITheOneProbe, Void> {

        public static ITheOneProbe probe;

        @Nullable
        @Override
        public Void apply(ITheOneProbe theOneProbe) {
            probe = theOneProbe;
            Tutorial2Block.LOGGER.info("Enabled support for The One Probe");
            probe.registerProvider(new IProbeInfoProvider() {
                @Override
                public ResourceLocation getID() {
                    return new ResourceLocation("tut2block:default");
                }

                @Override
                public void addProbeInfo(ProbeMode mode, IProbeInfo probeInfo, Player player, Level world, BlockState blockState, IProbeHitData data) {
                    if (blockState.getBlock() instanceof ProcessorBlock) {
                        Vec3 vec = data.getHitVec().subtract(data.getPos().getX(), data.getPos().getY(), data.getPos().getZ());
                        int quadrant = ProcessorBlock.getQuadrant(data.getSideHit(), vec);

                        ILayoutStyle defaultStyle = probeInfo.defaultLayoutStyle();
                        ILayoutStyle selectedStyle = probeInfo.defaultLayoutStyle().copy().borderColor(Color.rgb(255, 255, 255)).spacing(2);

                        Boolean button0 = blockState.getValue(ProcessorBlock.BUTTON10);
                        probeInfo.horizontal(quadrant == 0 ? selectedStyle : defaultStyle)
                                .text(Component.translatable(ProcessorBlockEntity.ACTION_MELT, button0 ? "On" : "Off"));
                        Boolean button1 = blockState.getValue(ProcessorBlock.BUTTON00);
                        probeInfo.horizontal(quadrant == 1 ? selectedStyle : defaultStyle)
                                .text(Component.translatable(ProcessorBlockEntity.ACTION_BREAK, button1 ? "On" : "Off"));
                        Boolean button2 = blockState.getValue(ProcessorBlock.BUTTON11);
                        probeInfo.horizontal(quadrant == 2 ? selectedStyle : defaultStyle)
                                .text(Component.translatable(ProcessorBlockEntity.ACTION_SOUND, button2 ? "On" : "Off"));
                        Boolean button3 = blockState.getValue(ProcessorBlock.BUTTON01);
                        probeInfo.horizontal(quadrant == 3 ? selectedStyle : defaultStyle)
                                .text(Component.translatable(ProcessorBlockEntity.ACTION_SPAWN, button3 ? "On" : "Off"));
                    }
                }
            });
            return null;
        }
    }
}

Now we have to call the register method from our Tutorial2Block class. Don't forget to also register this event on in the constructor of our mod:

        modEventBus.addListener(this::commonSetup);
    private void commonSetup(final FMLCommonSetupEvent event) {
        TopCompatibility.register();
    }